From a5eb13e269ac63d5c21fa248d32dd10b75f99f48 Mon Sep 17 00:00:00 2001 From: uwla Date: Sun, 16 Jun 2024 18:03:00 -0300 Subject: [PATCH] feat: balance channels modal Adds modal that allows users to manually balance channels using a slicers, or to evenly balance the channels automatically via button. --- .../common/BalanceChannelsButton.tsx | 32 +++++ .../common/BalanceChannelsModal.tsx | 87 +++++++++++++ src/components/designer/AutoMineButton.tsx | 4 +- src/components/designer/NetworkDesigner.tsx | 3 + src/components/designer/SyncButton.tsx | 9 +- src/components/network/NetworkActions.tsx | 10 +- src/i18n/locales/en-US.json | 6 + src/store/models/lightning.ts | 1 + src/store/models/modals.ts | 116 +++++++++++++++++- src/store/models/network.ts | 84 ++++++++++++- src/types/index.ts | 14 +++ src/utils/constants.ts | 2 + src/utils/network.ts | 14 +++ 13 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 src/components/common/BalanceChannelsButton.tsx create mode 100644 src/components/common/BalanceChannelsModal.tsx diff --git a/src/components/common/BalanceChannelsButton.tsx b/src/components/common/BalanceChannelsButton.tsx new file mode 100644 index 0000000000..7b16c00acb --- /dev/null +++ b/src/components/common/BalanceChannelsButton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { SwapOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Button } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { useStoreActions } from 'store'; +import { Network } from 'types'; + +const Styled = { + Button: styled(Button)` + margin-left: 8px; + `, +}; + +interface Props { + network: Network; +} + +const BalanceChannelsButton: React.FC = ({ network }) => { + const { l } = usePrefixedTranslation('cmps.common.BalanceChannelsButton'); + const { showBalanceChannels, resetBalanceChannels } = useStoreActions(s => s.modals); + + return ( + resetBalanceChannels(network).then(showBalanceChannels)} + > + {l('btn')} + + ); +}; + +export default BalanceChannelsButton; diff --git a/src/components/common/BalanceChannelsModal.tsx b/src/components/common/BalanceChannelsModal.tsx new file mode 100644 index 0000000000..ef8f620482 --- /dev/null +++ b/src/components/common/BalanceChannelsModal.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { PercentageOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Button, Col, Modal, Row, Slider } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { useStoreActions, useStoreState } from 'store'; +import { ChannelInfo, Network } from 'types'; +import { format } from 'utils/units'; + +interface Props { + network: Network; +} + +const BalanceChannelsModal: React.FC = ({ network }) => { + const { l } = usePrefixedTranslation('cmps.common.BalanceChannelsModal'); + const { visible, channelsInfo } = useStoreState(s => s.modals.balanceChannels); + const { + resetBalanceChannels, + hideBalanceChannels, + autoBalanceChannels, + manualBalanceChannels, + updateBalanceOfChannels, + } = useStoreActions(s => s.modals); + + const CustomModalFooter = ( + + + + + + + + + ); + + return ( + hideBalanceChannels()} + > + {/* sliders */} + {(channelsInfo || []).map((channel: ChannelInfo, index: number) => { + const { to, from, id, remoteBalance, localBalance, nextLocalBalance } = channel; + const total = Number(remoteBalance) + Number(localBalance); + return ( +
+ + + {from} +
+ {format(nextLocalBalance)} + + + {to} +
+ {format(total - nextLocalBalance)} + +
+ manualBalanceChannels({ value, index })} + min={0} + max={total} + /> +
+ ); + })} + {/* end sliders */} +
+ + + + + + + + +
+ ); +}; + +export default BalanceChannelsModal; diff --git a/src/components/designer/AutoMineButton.tsx b/src/components/designer/AutoMineButton.tsx index d23ff51dea..839fbe94ff 100644 --- a/src/components/designer/AutoMineButton.tsx +++ b/src/components/designer/AutoMineButton.tsx @@ -1,9 +1,9 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FieldTimeOutlined } from '@ant-design/icons'; import styled from '@emotion/styled'; -import { Button, Dropdown, Tooltip, MenuProps } from 'antd'; +import { Button, Dropdown, MenuProps, Tooltip } from 'antd'; import { ItemType } from 'antd/lib/menu/hooks/useItems'; import { usePrefixedTranslation } from 'hooks'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useStoreActions, useStoreState } from 'store'; import { AutoMineMode, Network } from 'types'; diff --git a/src/components/designer/NetworkDesigner.tsx b/src/components/designer/NetworkDesigner.tsx index a9ff391ab3..7ea4775551 100644 --- a/src/components/designer/NetworkDesigner.tsx +++ b/src/components/designer/NetworkDesigner.tsx @@ -9,6 +9,7 @@ import { useStoreActions, useStoreState } from 'store'; import { Network } from 'types'; import { Loader } from 'components/common'; import AdvancedOptionsModal from 'components/common/AdvancedOptionsModal'; +import BalanceChannelsModal from 'components/common/BalanceChannelsModal'; import SendOnChainModal from './bitcoind/actions/SendOnChainModal'; import { CanvasOuterDark, Link, NodeInner, Port, Ports } from './custom'; import { @@ -60,6 +61,7 @@ const NetworkDesigner: React.FC = ({ network, updateStateDelay = 3000 }) changeBackend, sendOnChain, advancedOptions, + balanceChannels, changeTapBackend, } = useStoreState(s => s.modals); @@ -104,6 +106,7 @@ const NetworkDesigner: React.FC = ({ network, updateStateDelay = 3000 }) {changeBackend.visible && } {sendOnChain.visible && } {advancedOptions.visible && } + {balanceChannels.visible && } {mintAsset.visible && } {newAddress.visible && } {changeTapBackend.visible && } diff --git a/src/components/designer/SyncButton.tsx b/src/components/designer/SyncButton.tsx index 205a2c3d2e..a2cf1ab7d2 100644 --- a/src/components/designer/SyncButton.tsx +++ b/src/components/designer/SyncButton.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { useAsyncCallback } from 'react-async-hook'; import { ReloadOutlined } from '@ant-design/icons'; import styled from '@emotion/styled'; @@ -17,9 +17,10 @@ const Styled = { interface Props { network: Network; + children?: ReactNode; } -const SyncButton: React.FC = ({ network }) => { +const SyncButton: React.FC = ({ children, network }) => { const { l } = usePrefixedTranslation('cmps.designer.SyncButton'); const { notify } = useStoreActions(s => s.app); const { syncChart } = useStoreActions(s => s.designer); @@ -42,7 +43,9 @@ const SyncButton: React.FC = ({ network }) => { icon={} onClick={syncChartAsync.execute} loading={syncChartAsync.loading} - /> + > + {children} + ); }; diff --git a/src/components/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index ad64a5eb48..323563f78c 100644 --- a/src/components/network/NetworkActions.tsx +++ b/src/components/network/NetworkActions.tsx @@ -1,3 +1,4 @@ +import React, { ReactNode, useCallback } from 'react'; import { CloseOutlined, ExportOutlined, @@ -11,15 +12,15 @@ import { import styled from '@emotion/styled'; import { Button, Divider, Dropdown, MenuProps, Tag } from 'antd'; import { ButtonType } from 'antd/lib/button'; -import AutoMineButton from 'components/designer/AutoMineButton'; -import { useMiningAsync } from 'hooks/useMiningAsync'; -import SyncButton from 'components/designer/SyncButton'; import { usePrefixedTranslation } from 'hooks'; -import React, { ReactNode, useCallback } from 'react'; +import { useMiningAsync } from 'hooks/useMiningAsync'; import { Status } from 'shared/types'; import { useStoreState } from 'store'; import { Network } from 'types'; import { getNetworkBackendId } from 'utils/network'; +import BalanceChannelsButton from 'components/common/BalanceChannelsButton'; +import AutoMineButton from 'components/designer/AutoMineButton'; +import SyncButton from 'components/designer/SyncButton'; const Styled = { Button: styled(Button)` @@ -129,6 +130,7 @@ const NetworkActions: React.FC = ({ {l('mineBtn')} + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 4e58a71449..91768eab1e 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -21,6 +21,12 @@ "cmps.common.AdvancedOptionsModal.cancelBtn": "Cancel", "cmps.common.AdvancedOptionsModal.success": "Updated advanced options for {{name}}", "cmps.common.AdvancedOptionsModal.error": "Failed to update options", + "cmps.common.BalanceChannelsButton.btn": "Balance channels", + "cmps.common.BalanceChannelsModal.title": "Balance Channels", + "cmps.common.BalanceChannelsModal.autoBalance": "Auto Balance", + "cmps.common.BalanceChannelsModal.update": "Update channels", + "cmps.common.BalanceChannelsModal.reset": "Reset", + "cmps.common.BalanceChannelsModal.close": "Close", "cmps.common.CopyIcon.message": "Copied {{label}} to clipboard", "cmps.common.NavMenu.createNetwork": "Create Network", "cmps.common.NavMenu.manageNodes": "Manage Images", diff --git a/src/store/models/lightning.ts b/src/store/models/lightning.ts index 1c55e6c484..a99af805db 100644 --- a/src/store/models/lightning.ts +++ b/src/store/models/lightning.ts @@ -129,6 +129,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/modals.ts b/src/store/models/modals.ts index 665602c69a..1b43b293f8 100644 --- a/src/store/models/modals.ts +++ b/src/store/models/modals.ts @@ -1,5 +1,7 @@ import { Action, action, Thunk, thunk } from 'easy-peasy'; -import { StoreInjections } from 'types'; +import { LightningNode } from 'shared/types'; +import { LightningNodeChannel } from 'lib/lightning/types'; +import { ChannelInfo, Network, PreInvoice, StoreInjections } from 'types'; import { RootModel } from './'; interface OpenChannelModel { @@ -35,6 +37,11 @@ interface AdvancedOptionsModel { defaultCommand?: string; } +interface BalanceChannelsModel { + visible: boolean; + channelsInfo?: ChannelInfo[]; +} + interface ImageUpdatesModel { visible: boolean; } @@ -80,6 +87,7 @@ export interface ModalsModel { createInvoice: CreateInvoiceModel; payInvoice: PayInvoiceModel; advancedOptions: AdvancedOptionsModel; + balanceChannels: BalanceChannelsModel; imageUpdates: ImageUpdatesModel; sendOnChain: SendOnChainModel; assetInfo: AssetInfoModel; @@ -102,6 +110,15 @@ export interface ModalsModel { setAdvancedOptions: Action; showAdvancedOptions: Thunk, StoreInjections>; hideAdvancedOptions: Thunk; + + setBalanceChannels: Action; + showBalanceChannels: Thunk; + hideBalanceChannels: Thunk; + resetBalanceChannels: Thunk; + manualBalanceChannels: Action; + autoBalanceChannels: Action; + updateBalanceOfChannels: Thunk; + setImageUpdates: Action; showImageUpdates: Thunk; hideImageUpdates: Thunk; @@ -138,6 +155,7 @@ const modalsModel: ModalsModel = { createInvoice: { visible: false }, payInvoice: { visible: false }, advancedOptions: { visible: false }, + balanceChannels: { visible: false }, imageUpdates: { visible: false }, sendOnChain: { visible: false }, assetInfo: { visible: false }, @@ -235,6 +253,102 @@ const modalsModel: ModalsModel = { defaultCommand: undefined, }); }), + setBalanceChannels: action((state, payload) => { + state.balanceChannels = { + ...state.balanceChannels, + ...payload, + }; + }), + showBalanceChannels: thunk(actions => { + actions.setBalanceChannels({ visible: true }); + }), + hideBalanceChannels: thunk(actions => { + actions.setBalanceChannels({ visible: false }); + }), + resetBalanceChannels: thunk( + async (actions, network, { getStoreActions, getStoreState }) => { + const channels = [] as LightningNodeChannel[]; + const { getChannels } = getStoreActions().lightning; + const { links } = getStoreState().designer.activeChart; + + const id2Node = {} as Record; + const promisesToAwait = [] as Promise[]; + const channelsInfo = [] as ChannelInfo[]; + + for (const node of network.nodes.lightning) { + promisesToAwait.push( + getChannels(node).then((nodeChannels: LightningNodeChannel[]) => { + channels.push(...nodeChannels); + id2Node[node.name] = node; + }), + ); + } + await Promise.all(promisesToAwait); + + for (const channel of channels) { + const { uniqueId: id, localBalance, remoteBalance } = channel; + if (!links[id]) continue; + const from = links[id].from.nodeId; + const to = links[id].to.nodeId as string; + const nextLocalBalance = Number(localBalance); + channelsInfo.push({ + id, + to, + from, + localBalance, + remoteBalance, + nextLocalBalance, + }); + } + + actions.setBalanceChannels({ channelsInfo } as BalanceChannelsModel); + }, + ), + manualBalanceChannels: action((state, { value, index }) => { + const { channelsInfo: info } = state.balanceChannels; + if (info && info[index]) { + info[index].nextLocalBalance = value; + state.balanceChannels.channelsInfo = info; + } + }), + autoBalanceChannels: action(state => { + const { channelsInfo } = state.balanceChannels; + if (!channelsInfo) { + return; + } + for (let index = 0; index < channelsInfo.length; index += 1) { + const { localBalance, remoteBalance } = channelsInfo[index]; + const halfAmount = Math.floor((Number(localBalance) + Number(remoteBalance)) / 2); + channelsInfo[index].nextLocalBalance = halfAmount; + } + state.balanceChannels = { + ...state.balanceChannels, + ...channelsInfo, + }; + }), + updateBalanceOfChannels: thunk( + (actions, network, { getStoreActions, getStoreState }) => { + const { notify } = getStoreActions().app; + const { balanceChannels } = getStoreActions().network; + const { channelsInfo } = getStoreState().modals.balanceChannels; + + if (!channelsInfo) { + return; + } + + const toPay = [] as PreInvoice[]; + for (const { id, localBalance, nextLocalBalance } of channelsInfo) { + if (Number(localBalance) !== nextLocalBalance) { + toPay.push({ channelId: id, nextLocalBalance }); + } + } + + balanceChannels({ id: network.id, toPay }) + .then(() => notify({ message: 'Channels balanced!' })) + .then(() => actions.hideBalanceChannels()); + }, + ), + setImageUpdates: action((state, payload) => { state.imageUpdates = { ...state.imageUpdates, diff --git a/src/store/models/network.ts b/src/store/models/network.ts index fce0ad1b0b..18ef08b8b3 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -13,7 +13,8 @@ import { TapdNode, TapNode, } from 'shared/types'; -import { AutoMineMode, CustomImage, Network, StoreInjections } from 'types'; +import { LightningNodeChannel } from 'lib/lightning/types'; +import { AutoMineMode, CustomImage, Network, PreInvoice, StoreInjections } from 'types'; import { delay } from 'utils/async'; import { initChartFromNetwork } from 'utils/chart'; import { APP_VERSION, DOCKER_REPO } from 'utils/constants'; @@ -26,6 +27,7 @@ import { createNetwork, createTapdNetworkNode, filterCompatibleBackends, + getInvoicePayload, getMissingImages, getOpenPorts, importNetworkFromZip, @@ -179,6 +181,14 @@ export interface NetworkModel { setAutoMineMode: Action; setMiningState: Action; mineBlock: Thunk; + + /* */ + balanceChannels: Thunk< + NetworkModel, + { id: number; toPay: PreInvoice[] }, + StoreInjections, + RootModel + >; } const networkModel: NetworkModel = { @@ -976,6 +986,78 @@ const networkModel: NetworkModel = { actions.setAutoMineMode({ id, mode }); }), + balanceChannels: thunk( + async (actions, { id, toPay }, { 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; + + // we solve all promises at once to avoid issues with promises inside loops + let promises = [] as Promise[]; + + // 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; + const id2channel = {} as Record; + + // We store the promises in an array, to later on resolve them all, + // because promises inside loops may cause undesirable effects. + for (const node of lnNodes) { + id2Node[node.name] = node; + promises.push( + getChannels(node).then((nodeChannels: LightningNodeChannel[]) => + channels.push(...nodeChannels), + ), + ); + } + + // resolve them all + await Promise.all(promises); + + for (const channel of channels) { + id2channel[channel.uniqueId] = channel; + } + + const minimumSatsDifference = 50; + const links = getStoreState().designer.activeChart.links; + promises = []; + + for (const { channelId, nextLocalBalance } of toPay) { + const channel = id2channel[channelId]; + const { to, from } = links[channelId]; + const fromNode = id2Node[from.nodeId as string]; + const toNode = id2Node[to.nodeId as string]; + const { source, target, amount } = getInvoicePayload( + channel, + fromNode, + toNode, + nextLocalBalance, + ); + + // Skip balancing if amount is too small. + if (amount < minimumSatsDifference) { + continue; + } + + console.log({ amount, source, target }); + // const payInvoicePromise = new Promise(async () => { + // const invoice = await await payInvoice({ node: source, invoice }); + // }); + // promises.push(payInvoicePromise); + + promises.push( + createInvoice({ node: target, amount }).then(invoice => + payInvoice({ node: source, invoice }), + ), + ); + } + + await Promise.all(promises); + }, + ), }; export default networkModel; diff --git a/src/types/index.ts b/src/types/index.ts index 7292f52a2e..c418a9178f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -230,3 +230,17 @@ export enum AutoMineMode { Auto5m = 300, Auto10m = 600, } + +export interface ChannelInfo { + id: string; + to: string; + from: string; + localBalance: string; + remoteBalance: string; + nextLocalBalance: number; +} + +export interface PreInvoice { + channelId: string; + nextLocalBalance: number; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 4c88a5f7e5..521ea322d8 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -172,6 +172,8 @@ export const dockerConfigs: Record = { '--printToConsole=true', '--on-chain-fees.feerate-tolerance.ratio-low=0.00001', '--on-chain-fees.feerate-tolerance.ratio-high=10000.0', + '--channel.max-htlc-value-in-flight-percent=100', + '--channel.max-htlc-value-in-flight-msat=5000000000000', // 50 BTC since 1000 msats = 1 sat = 1/10^7 btc ].join('\n '), // if vars are modified, also update composeFile.ts & the i18n strings for cmps.nodes.CommandVariables variables: ['name', 'eclairPass', 'backendName', 'rpcUser', 'rpcPass'], diff --git a/src/utils/network.ts b/src/utils/network.ts index 0a0a32a0b0..f4107cf64f 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, @@ -59,6 +60,19 @@ const groupNodes = (network: Network) => { }; }; +export const getInvoicePayload = ( + channel: LightningNodeChannel, + localNode: LightningNode, + remoteNode: LightningNode, + nextLocalBalance: number, +) => { + const localBalance = Number(channel.localBalance); + const amount = Math.abs(localBalance - nextLocalBalance); + const source = localBalance > nextLocalBalance ? localNode : remoteNode; + const target = localBalance > nextLocalBalance ? remoteNode : localNode; + return { source, target, amount }; +}; + export const getImageCommand = ( images: ManagedImage[], implementation: NodeImplementation,