Skip to content

Commit

Permalink
feat: enables manual balancing of channels
Browse files Browse the repository at this point in the history
User can manually balance channels using a slicer. The channels are
shown in a modal after clicking a button.

NOTE: this is a prototype, not working yet.
  • Loading branch information
uwla committed May 16, 2024
1 parent 4b2c287 commit 4cd78dd
Show file tree
Hide file tree
Showing 8 changed files with 1,601 additions and 1,737 deletions.
6 changes: 3 additions & 3 deletions src/components/designer/AutoMineButton.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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';

const Styled = {
Button: styled(Button)`
margin-left: 8px;
width: 100%;
`,
RemainingBar: styled.div`
transition: width 400ms ease-in-out;
Expand Down
122 changes: 122 additions & 0 deletions src/components/designer/BalanceChannelsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { Button, Col, Modal, Row, Slider } from 'antd';
import { LightningNode } from 'shared/types';
import { LightningNodeChannel } from 'lib/lightning/types';
import { useStoreActions, useStoreState } from 'store';
import { Network } from 'types';

const Styled = {
Button: styled(Button)`
width: 100%;
`,
};

interface Props {
network: Network;
}

interface ChannelInfo {
i: number;
id: string;
to: string;
from: string;
localBalance: string;
remoteBalance: string;
nextLocalBalance: number;
}

const AutoBalanceButton: React.FC<Props> = ({ network }) => {
const { getChannels } = useStoreActions(s => s.lightning);
const { links } = useStoreState(s => s.designer.activeChart);
// const { notify } = useStoreActions(s => s.app);
const [visible, setVisible] = useState(false);
const [info, setInfo] = useState([] as ChannelInfo[]);

const showModal = () => setVisible(true);
const hideModal = () => setVisible(false);

// Store all channels in an array and build a map nodeName->node.
async function updateInfo() {
const channels = [] as LightningNodeChannel[];
const id2Node = {} as Record<string, LightningNode>;
const promisesToAwait = [] as Promise<unknown>[];

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);

const info = [] as ChannelInfo[];
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);
const i = info.length;
info.push({ i, id, to, from, localBalance, remoteBalance, nextLocalBalance });
}
setInfo(info);
}

const setNextBalance = (value: number, index: number) => {
info[index].nextLocalBalance = value;
setInfo([...info]);
};

useEffect(() => {
updateInfo();
}, []);

return (
<>
<Styled.Button onClick={showModal}>Balance channels</Styled.Button>
<Modal
title="Balance Channels"
open={visible}
// onOk={handleClick}
onCancel={hideModal}
>
{/* sliders */}
{info.map((channel: ChannelInfo) => {
const { to, from, id, i, remoteBalance, localBalance, nextLocalBalance } =
channel;
const total = Number(remoteBalance) + Number(localBalance);
return (
<div key={id}>
<Row>
<Col span={12}>
{from}
<br />
{nextLocalBalance}
</Col>
<Col span={12} style={{ textAlign: 'right' }}>
{to}
<br />
{total - nextLocalBalance}
</Col>
</Row>
<Slider
value={nextLocalBalance}
onChange={value => setNextBalance(value, i)}
min={0}
max={total}
/>
</div>
);
})}
{/* end sliders */}
<br />
<Styled.Button onClick={updateInfo}>Refresh</Styled.Button>
</Modal>
</>
);
};

export default AutoBalanceButton;
13 changes: 7 additions & 6 deletions src/components/designer/SyncButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,17 +9,16 @@ import { Network } from 'types';

const Styled = {
Button: styled(Button)`
margin-left: 8px;
font-size: 18px;
padding: 2px 0 !important;
width: 100%;
`,
};

interface Props {
network: Network;
children?: ReactNode;
}

const SyncButton: React.FC<Props> = ({ network }) => {
const SyncButton: React.FC<Props> = ({ children, network }) => {
const { l } = usePrefixedTranslation('cmps.designer.SyncButton');
const { notify } = useStoreActions(s => s.app);
const { syncChart } = useStoreActions(s => s.designer);
Expand All @@ -42,7 +41,9 @@ const SyncButton: React.FC<Props> = ({ network }) => {
icon={<ReloadOutlined />}
onClick={syncChartAsync.execute}
loading={syncChartAsync.loading}
/>
>
{children}
</Styled.Button>
</Tooltip>
);
};
Expand Down
58 changes: 44 additions & 14 deletions src/components/network/NetworkActions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React, { ReactNode, useCallback } from 'react';
import {
CloseOutlined,
ExportOutlined,
Expand All @@ -11,20 +12,23 @@ 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 AutoMineButton from 'components/designer/AutoMineButton';
import BalanceChannelsButton from 'components/designer/BalanceChannelsButton';
import SyncButton from 'components/designer/SyncButton';

const Styled = {
Button: styled(Button)`
margin-left: 0;
`,
ButtonBlock: styled(Button)`
width: 100%;
`,
Dropdown: styled(Dropdown)`
margin-left: 12px;
`,
Expand Down Expand Up @@ -110,26 +114,52 @@ const NetworkActions: React.FC<Props> = ({
}
}, []);

const items: MenuProps['items'] = [
const networkActions: MenuProps['items'] = [
{ key: 'rename', label: l('menuRename'), icon: <FormOutlined /> },
{ key: 'export', label: l('menuExport'), icon: <ExportOutlined /> },
{ key: 'delete', label: l('menuDelete'), icon: <CloseOutlined /> },
];

const QuickMineButton = () => (
<Styled.ButtonBlock
onClick={mineAsync.execute}
loading={mineAsync.loading}
icon={<ToolOutlined />}
>
{l('mineBtn')}
</Styled.ButtonBlock>
);

const networkQuickActions: MenuProps['items'] = [
{
key: 'sync',
label: <SyncButton network={network}>Synchronize</SyncButton>,
},
{
key: 'balance',
label: <BalanceChannelsButton network={network} />,
},
{
key: 'quickMine',
label: <QuickMineButton />,
},
{
key: 'autoMine',
label: <AutoMineButton network={network} />,
},
];

return (
<>
{bitcoinNode.status === Status.Started && nodeState?.chainInfo && (
<>
<Tag>height: {nodeState.chainInfo.blocks}</Tag>
<Button
onClick={mineAsync.execute}
loading={mineAsync.loading}
icon={<ToolOutlined />}
<Styled.Dropdown
key="options"
menu={{ theme: 'dark', items: networkQuickActions }}
>
{l('mineBtn')}
</Button>
<AutoMineButton network={network} />
<SyncButton network={network} />
<Button icon={<MoreOutlined />}>Quick Actions</Button>
</Styled.Dropdown>
<Divider type="vertical" />
</>
)}
Expand All @@ -146,7 +176,7 @@ const NetworkActions: React.FC<Props> = ({
</Styled.Button>
<Styled.Dropdown
key="options"
menu={{ theme: 'dark', items, onClick: handleClick }}
menu={{ theme: 'dark', items: networkActions, onClick: handleClick }}
>
<Button icon={<MoreOutlined />} />
</Styled.Dropdown>
Expand Down
1 change: 1 addition & 0 deletions src/store/models/lightning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions src/store/models/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +181,9 @@ export interface NetworkModel {
setAutoMineMode: Action<NetworkModel, { id: number; mode: AutoMineMode }>;
setMiningState: Action<NetworkModel, { id: number; mining: boolean }>;
mineBlock: Thunk<NetworkModel, { id: number }, StoreInjections, RootModel>;

/* */
autoBalanceChannels: Thunk<NetworkModel, { id: number }, StoreInjections, RootModel>;
}

const networkModel: NetworkModel = {
Expand Down Expand Up @@ -961,6 +966,50 @@ 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<string, LightningNode>;
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<unknown>[];

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 { source, target, amount } = balanceChannel(channel, fromNode, toNode);

// Skip balancing if amount is too small.
if (amount < minimumSatsDifference) continue;
console.log(`[AUTO BALANCE] ${source.name} -> ${amount} -> ${target.name}`);

// Let's avoid problems with promises inside loops.
promisesToAwait.push(
createInvoice({ node: target, amount }).then(invoice =>
payInvoice({ node: source, amount, invoice }),
),
);
}

await Promise.all(promisesToAwait);
},
),
};

export default networkModel;
14 changes: 14 additions & 0 deletions src/utils/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
TapNode,
} from 'shared/types';
import { createIpcSender } from 'lib/ipc/ipcService';
import { LightningNodeChannel } from 'lib/lightning/types';
import {
AutoMineMode,
CustomImage,
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 4cd78dd

Please sign in to comment.