diff --git a/src/components/common/BalanceChannelsButton.tsx b/src/components/common/BalanceChannelsButton.tsx new file mode 100644 index 000000000..27352c20a --- /dev/null +++ b/src/components/common/BalanceChannelsButton.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { SwapOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Button, Tooltip } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { useStoreActions, useStoreState } 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 } = useStoreActions(s => s.modals); + const { resetChannelsInfo } = useStoreActions(s => s.lightning); + const { channelsInfo } = useStoreState(s => s.lightning); + + const showModal = async () => { + await showBalanceChannels(); + await resetChannelsInfo(network); + }; + + return ( + channelsInfo.length > 0 && ( + + + + + + ) + ); +}; + +export default BalanceChannelsButton; diff --git a/src/components/common/BalanceChannelsModal.tsx b/src/components/common/BalanceChannelsModal.tsx new file mode 100644 index 000000000..dea7a9cd3 --- /dev/null +++ b/src/components/common/BalanceChannelsModal.tsx @@ -0,0 +1,86 @@ +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'; +import styled from '@emotion/styled'; + +interface Props { + network: Network; +} + +const Styled = { + Button: styled(Button)` + width: 100%; + `, +}; + +const BalanceChannelsModal: React.FC = ({ network }) => { + const { l } = usePrefixedTranslation('cmps.common.BalanceChannelsModal'); + const { channelsInfo } = useStoreState(s => s.lightning); + const { visible } = useStoreState(s => s.modals.balanceChannels); + const { hideBalanceChannels } = useStoreActions(s => s.modals); + const { + resetChannelsInfo, + manualBalanceChannelsInfo, + autoBalanceChannelsInfo, + updateBalanceOfChannels, + } = useStoreActions(s => s.lightning); + + return ( + updateBalanceOfChannels(network)} + onCancel={() => 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)} + +
+ manualBalanceChannelsInfo({ value, index })} + min={0} + max={total} + /> +
+ ); + })} + {/* end sliders */} +
+ + + resetChannelsInfo(network)}> + {l('reset')} + + + + autoBalanceChannelsInfo()}> + {l('autoBalance')} + + + +
+ ); +}; + +export default BalanceChannelsModal; diff --git a/src/components/designer/AutoMineButton.tsx b/src/components/designer/AutoMineButton.tsx index d23ff51de..839fbe94f 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 a9ff391ab..7ea477555 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/network/NetworkActions.tsx b/src/components/network/NetworkActions.tsx index ad64a5eb4..323563f78 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 4e58a7144..6223574f2 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 1c55e6c48..a38595762 100644 --- a/src/store/models/lightning.ts +++ b/src/store/models/lightning.ts @@ -2,11 +2,13 @@ import { Action, action, Thunk, thunk, ThunkOn, thunkOn } from 'easy-peasy'; import { throttle } from 'lodash'; import { LightningNode, Status } from 'shared/types'; import * as PLN from 'lib/lightning/types'; -import { Network, StoreInjections } from 'types'; +import { ChannelInfo, Network, PreInvoice, StoreInjections } from 'types'; import { delay } from 'utils/async'; import { BLOCKS_TIL_CONFIRMED } from 'utils/constants'; +import { getInvoicePayload } from 'utils/network'; import { fromSatsNumeric } from 'utils/units'; import { RootModel } from './'; +import { LightningNodeChannel } from 'lib/lightning/types'; export interface LightningNodeMapping { [key: string]: LightningNodeModel; @@ -45,6 +47,7 @@ export interface PayInvoicePayload { export interface LightningModel { nodes: LightningNodeMapping; + channelsInfo: ChannelInfo[]; removeNode: Action; clearNodes: Action; setInfo: Action; @@ -88,11 +91,23 @@ export interface LightningModel { addListeners: Thunk; removeListeners: Thunk; addChannelListeners: Thunk; + setChannelsInfo: Action; + resetChannelsInfo: Thunk; + manualBalanceChannelsInfo: Action; + autoBalanceChannelsInfo: Action; + updateBalanceOfChannels: Thunk; + balanceChannels: Thunk< + LightningModel, + { id: number; toPay: PreInvoice[] }, + StoreInjections, + RootModel + >; } const lightningModel: LightningModel = { // state properties nodes: {}, + channelsInfo: [], // reducer actions (mutations allowed thx to immer) removeNode: action((state, name) => { if (state.nodes[name]) { @@ -129,6 +144,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); @@ -213,6 +229,8 @@ const lightningModel: LightningModel = { await actions.waitForNodes([from, to]); // synchronize the chart with the new channel await getStoreActions().designer.syncChart(network); + // synchronize channels info + await actions.resetChannelsInfo(network); }, ), closeChannel: thunk( @@ -236,6 +254,8 @@ const lightningModel: LightningModel = { await actions.waitForNodes([node]); // synchronize the chart with the new channel await getStoreActions().designer.syncChart(network); + // synchronize channels info + await actions.resetChannelsInfo(network); }, ), createInvoice: thunk(async (actions, { node, amount, memo }, { injections }) => { @@ -334,6 +354,125 @@ const lightningModel: LightningModel = { } }, ), + setChannelsInfo: action((state, payload) => { + state.channelsInfo = payload; + }), + resetChannelsInfo: thunk(async (actions, network, { getStoreState }) => { + const channels = [] as LightningNodeChannel[]; + const { getChannels } = actions; + const { links } = getStoreState().designer.activeChart; + + const id2Node = {} as Record; + const channelsInfo = [] as ChannelInfo[]; + + await Promise.all( + network.nodes.lightning.map(async node => { + const nodeChannels = await getChannels(node); + channels.push(...nodeChannels); + id2Node[node.name] = node; + }), + ); + + 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; + if (!to) continue; + const nextLocalBalance = Number(localBalance); + channelsInfo.push({ + id, + to, + from, + localBalance, + remoteBalance, + nextLocalBalance, + }); + } + + actions.setChannelsInfo(channelsInfo); + }), + manualBalanceChannelsInfo: action((state, { value, index }) => { + const { channelsInfo: info } = state; + if (info && info[index]) { + info[index].nextLocalBalance = value; + state.channelsInfo = info; + } + }), + autoBalanceChannelsInfo: action(state => { + const { channelsInfo } = state; + 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.channelsInfo = channelsInfo; + }), + updateBalanceOfChannels: thunk( + async (actions, network, { getStoreActions, getState }) => { + const { notify } = getStoreActions().app; + const { hideBalanceChannels } = getStoreActions().modals; + const { channelsInfo } = getState(); + + if (!channelsInfo) return; + + const toPay: PreInvoice[] = channelsInfo + .filter(c => Number(c.localBalance) !== c.nextLocalBalance) + .map(c => ({ channelId: c.id, nextLocalBalance: c.nextLocalBalance })); + + await actions.balanceChannels({ id: network.id, toPay }); + await hideBalanceChannels(); + notify({ message: 'Channels balanced!' }); + }, + ), + balanceChannels: thunk(async (actions, { id, toPay }, { getStoreState }) => { + const { networks } = getStoreState().network; + const network = networks.find(n => n.id === id); + if (!network) throw new Error('networkByIdErr'); + const { createInvoice, payInvoice, getChannels } = actions; + const lnNodes = network.nodes.lightning; + const channels = [] as LightningNodeChannel[]; + const id2Node = {} as Record; + const id2channel = {} as Record; + + await Promise.all( + lnNodes.map(async node => { + id2Node[node.name] = node; + const nodeChannels = await getChannels(node); + channels.push(...nodeChannels); + }), + ); + + channels.forEach(channel => (id2channel[channel.uniqueId] = channel)); + const minimumSatsDifference = 50; + const links = getStoreState().designer.activeChart.links; + + await Promise.all( + toPay.map(async ({ channelId, nextLocalBalance }) => { + const channel = id2channel[channelId]; + const { to, from } = links[channelId]; + if (!to.nodeId) return; + const fromNode = id2Node[from.nodeId]; + const toNode = id2Node[to.nodeId]; + const payload = getInvoicePayload(channel, fromNode, toNode, nextLocalBalance); + + if (payload.amount < minimumSatsDifference) return; + + const invoice = await createInvoice({ + node: payload.target, + amount: payload.amount, + }); + + await payInvoice({ + invoice, + node: payload.source, + }); + }), + ); + }), }; export default lightningModel; diff --git a/src/store/models/modals.ts b/src/store/models/modals.ts index 665602c69..929f8c487 100644 --- a/src/store/models/modals.ts +++ b/src/store/models/modals.ts @@ -35,6 +35,10 @@ interface AdvancedOptionsModel { defaultCommand?: string; } +interface BalanceChannelsModel { + visible: boolean; +} + interface ImageUpdatesModel { visible: boolean; } @@ -80,6 +84,7 @@ export interface ModalsModel { createInvoice: CreateInvoiceModel; payInvoice: PayInvoiceModel; advancedOptions: AdvancedOptionsModel; + balanceChannels: BalanceChannelsModel; imageUpdates: ImageUpdatesModel; sendOnChain: SendOnChainModel; assetInfo: AssetInfoModel; @@ -102,6 +107,9 @@ export interface ModalsModel { setAdvancedOptions: Action; showAdvancedOptions: Thunk, StoreInjections>; hideAdvancedOptions: Thunk; + hideBalanceChannels: Thunk; + showBalanceChannels: Thunk; + setBalanceChannels: Action; setImageUpdates: Action; showImageUpdates: Thunk; hideImageUpdates: Thunk; @@ -138,6 +146,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 +244,19 @@ 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 }); + }), + setImageUpdates: action((state, payload) => { state.imageUpdates = { ...state.imageUpdates, diff --git a/src/types/index.ts b/src/types/index.ts index 7292f52a2..c418a9178 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 4c88a5f7e..521ea322d 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 0a0a32a0b..f4107cf64 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,